// File cache
// Copyright (C) 2011-2012 Jani Kajala. Licensed under MIT-license.
// Dependencies: Node.js (only core modules)

var fs 				= require('fs');
var path			= require('path');
var url				= require('url');

//------------------------------------------------------------------------------------------------
// Helpers
//------------------------------------------------------------------------------------------------

/**
 * Returns human-readable description of byte count.
 */
function b2s( n ) {
	if ( n < 1000 )
		return Math.round(n).toFixed(0)+" B";
	else if ( n < 1000000 )
		return Math.round(n/1000).toFixed(1)+" kB";
	else
		return Math.round(n/1000000000).toFixed(1)+" MB";
};

//------------------------------------------------------------------------------------------------
// Initialization
//------------------------------------------------------------------------------------------------

/**
 * Creates file cache with specified root path.
 * All requests to the cache are relative to the root path.
 * @param opt Options: 
 * @param opt.rootPath Root path for the files. Do NOT append '/' to the end.
 * @param opt.maxSize Max size in bytes. Files are dropped out of cache in least-recently-used order. If 0 then caching disabled.
 * @param opt.maxFileSize Max file for single file to be cached. Default: 50% of maxSize.
 * @param opt.expires Default expires in seconds for the files in cache. Default: 31536000.
 * @param opt.verbose Verbose debug output. Default: false.
 */
function FileCache( opt ) {
	var self = this;
	for (var k in opt)
		self[k] = opt[k];

	var reqs = ['rootPath','maxSize'];
	for (var i = 0 ; i < reqs.length ; ++i) {
		var k = reqs[i];
		if ( typeof opt[k] == 'undefined' )
			throw new Error( "FileCache: opt."+k+" required in constructor" );
	}
	
	var defaults = {
		size: 0,
		maxFileSize: self.maxSize/2,
		expires: 31536000,
		verbose: false
	};
	for (var k in defaults) {
		if ( typeof opt[k] == 'undefined' )
			self[k] = defaults[k];
	}
	
	this.cache = {};
	
	// statistics
	this.statMisses = 0;
	this.statHits = 0;
	this.statDropLRUs = 0;
}

//------------------------------------------------------------------------------------------------
// Information retrieval
//------------------------------------------------------------------------------------------------

/**
 * Returns statistics object with following properties:
 * 'misses': number of requests which didn't have file in cache
 * 'hits': number of requests which used the cached file
 * 'total': Total number of requests made to the FileCache
 * 'dropLRUs': number of times least-recently-used file was dropped from cache
 */
FileCache.prototype.statistics = function() {
	return {
		misses: this.statMisses,
		hits: this.statHits,
		dropLRUs: this.statDropLRUs
	};
};

/**
 * Returns list of files in specified directory relative to FileCache.rootPath.
 * Useful for mapping bunch of files at once as server routes.
 * @param dir Directory name relative to FileCache.rootPath.
 * @param recurse Recurse subdirectories. Default: false.
 * @return List of cache file names relative to the rootPath. 
 */
FileCache.prototype.dirSync = function( dir, recurse, out ) {
	if (typeof out === 'undefined') out = [];
	var files = fs.readdirSync( this.rootPath+dir, true );
	for ( var i = 0 ; i < files.length ; ++i ) {
		var fname = dir+'/'+files[i];
		out.push( fname );
		if (recurse) {
			var fstat = fs.statSync( this.rootPath+fname );
			if (fstat.isDirectory())
				this.dirSync( fname, recurse, out );
		}
	}
	return out;
};

//------------------------------------------------------------------------------------------------
// Output
//------------------------------------------------------------------------------------------------

/** 
 * Most common file suffix Content-Type mappings.
 */
FileCache.prototype.contentTypes = {
	// type application
	'.gz': 'application/x-gzip',
	'.zip': 'application/zip',

	// type image
	'.png': 'image/png',
	'.gif': 'image/gif',
	'.jpeg': 'image/jpeg',
	'.jpg': 'image/jpeg',
	
	// type text
	'.pdf': 'application/pdf',
	'.css': 'text/css; charset=utf-8',
	'.csv': 'text/csv; charset=utf-8',
	'.html': 'text/html; charset=utf-8',
	'.js': 'application/x-javascript; charset=utf-8',
	'.txt': 'text/plain; charset=utf-8',
	'.xml': 'text/xml; charset=utf-8'
};

/**
 * Drops least recently used file from cache
 */
FileCache.prototype.dropLRU = function() {
	this.statDropLRUs++;
	var ts0 = (new Date()).getTime();
	var k0 = '';
	for (var k in this.cache) {
		var f = this.cache[k];
		if ( f.ts && f.ts < ts0 ) {
			ts0 = f.ts;
			k0 = k;
		}
	}
	if (k0) {
		if (this.verbose) console.log( "FileCache.dropLRU: Dropping "+k0 );
		this.size -= this.cache[k0].buf.length;
		delete this.cache[k0];
	}
};

/**
 * Writes Buffer using specified Content-Type and Cache-Control/Expires to ServerResponse.
 * 
 * @param buf Buffer/string
 * @param res ServerResponse object
 * @param ct Content-Type. If missing/false then no Content-Type is written.
 * @param expires Expires/Cache-Control in seconds. If missing/false then no Cache-Control/Expires is written.
 */
FileCache.prototype.send = function( buf, res, ct, expires ) {
	if ( ct ) {
		res.setHeader( 'Content-Type', ct );
	}
	if ( expires ) {
		res.setHeader( 'Cache-Control', 'max-age='+expires );
		res.setHeader( 'Expires', (new Date((new Date()).getTime()+expires*1000)).toGMTString() );
	}
	res.setHeader( 'Content-Length', buf.length );
	res.end( buf );
};

/**
 * Handles ServerRequest and outputs file to ServerResponse, and calls next(err) when done.
 */
FileCache.prototype.handle = function( req, res, next ) {
	var path = (typeof req.urlc == 'object' ? req.urlc.pathname : url.parse(req.url).pathname);
	this.sendFile( path, this.expires, res, next );
};

/**
 * Handles ServerRequest and outputs file to ServerResponse with custom Expires/Cache-Control seconds, and calls next(err) when done.
 */
FileCache.prototype.handleExpires = function( expires, req, res, next ) {
	var path = (typeof req.urlc == 'object' ? req.urlc.pathname : url.parse(req.url).pathname);
	this.sendFile( path, expires, res, next );
};

/** 
 * Writes file to ServerResponse.
 * @param pathname Path name relative to the path constructor. For example: FileCache('/var/www').sendFile('/img/1.png') outputs '/var/www/img/1.png'.
 * @param expires Optional expires in seconds. If missing default value from constructor options is used.
 * @param res ServerResponse object
 * @param next The next function to execute after this is finished.
 */ 
FileCache.prototype.sendFile = function( pathname, expires, res, next ) {
	var self = this;

	// expires is optional, use default if missing
	if (typeof expires == 'object') {
		next = res;
		res = expires;
		expires = this.expires;
	}

	var ts = (new Date()).getTime();
	var f = this.cache[pathname];
	if ( f && ts-f.ts < expires ) {
		this.statMisses++;
		if (this.verbose) console.log( "FileCache.sendFile: Sending "+pathname );
		self.send( f.buf, res, f.ct, expires );
		f.ts = ts;
		if (typeof next == 'function') next();
	}
	else {
		this.statHits++;
		var fullpath = this.rootPath+pathname;
		if (this.verbose) console.log( "FileCache.sendFile: Reading "+fullpath );
		fs.readFile( fullpath, function(err,buf) {
			if (err) {console.error( "ERROR: Failed to load "+pathname ); return typeof next == 'function' ? next(err) : false;}
			
			var f = {buf:buf, ct:self.contentTypes[ path.extname(pathname) ]};
			var cacheable = buf.length < self.maxFileSize;
			
			if ( cacheable && self.size+buf.length > self.maxSize )
				self.dropLRU();
			
			if ( cacheable && self.size+buf.length < self.maxSize ) {
				self.size += buf.length;
				if (self.verbose) console.log( "FileCache.sendFile: Caching "+pathname+" (size="+b2s(buf.length)+", cache size="+self.size+")" );
				self.cache[pathname] = f;
			}
			else if (self.verbose) {
				console.log( "FileCache.sendFile: Sending "+pathname+" without caching (size="+b2s(buf.length)+", max file="+b2s(self.maxFileSize)+", cache size="+b2s(self.size)+"/"+b2s(self.maxSize)+")" );
			}
			
			self.send( f.buf, res, f.ct, expires );
			f.ts = ts;
			if (typeof next == 'function') next();
		} );
	}
};

//------------------------------------------------------------------------------------------------
// module.exports
//------------------------------------------------------------------------------------------------

module.exports = FileCache;
